Skip to content

release: PLAN03-1 devbase env export / import + S3 backend#13

Closed
takemi-ohama wants to merge 11 commits into
mainfrom
release/PLAN03-1
Closed

release: PLAN03-1 devbase env export / import + S3 backend#13
takemi-ohama wants to merge 11 commits into
mainfrom
release/PLAN03-1

Conversation

@takemi-ohama
Copy link
Copy Markdown
Contributor

@takemi-ohama takemi-ohama commented May 21, 2026

Summary

個別 PR (全て merge 済み)

主な追加機能

  • devbase env export — Local / Stdio / S3 backend 対応、age 暗号化 (X25519 / OpenSSH ed25519,rsa / passphrase / @PATH)、tar.gz + manifest.yml バンドル
  • devbase env import — merge (keep-existing / prefer-incoming) / --replace-keys / --replace / --dry-run / 2 フェーズ書き出し / backup (--keep-last)
  • S3 backend — SSE-KMS / SSE-S3 強制、GetBucketEncryption 事前確認、--unsafe-allow-unencrypted-bucket バイパス
  • ドキュメント — docs/user/env-export-import.md (利用者向けガイド) + README リンク + CHANGELOG

Test plan (結合観点)

  • PR1, PR2 merge 後に devbase env export / devbase env import がローカルで動作
  • PR3 merge 後に S3 backend を含むラウンドトリップ
  • CLI 引数 / manifest version の整合性が PR 跨ぎで保たれる
  • 別マシンで export → import を 1 回手動検証し devbase env list が一致すること (main merge 前にユーザ実施推奨)

@takemi-ohama takemi-ohama marked this pull request as ready for review May 21, 2026 01:43
* chore(PLAN03-1-export-local): Draft PR 作成

* feat(env): devbase env export を追加 (PLAN03-1 PR1)

- lib/devbase/env/bundle.py: tar.gz + manifest.yml バンドル構築/展開、sha256 検証、未知 version 拒否、パストラバーサル拒否
- lib/devbase/env/cipher.py: pyrage 経由の age 暗号化/復号 (X25519 / OpenSSH ed25519,rsa / passphrase / @path 参照)
- lib/devbase/env/storage.py: Local + Stdio backend、s3/gs は本 PR では未実装で明示エラー
- lib/devbase/env/io_export.py: 機密キー検知警告、既定鍵 (~/.ssh/id_rsa.pub) 自動利用、--passphrase-stdin と DEST='-' 併用拒否
- cli.py / commands/env.py: env export サブコマンド登録 + SUBCMD_MAP 更新
- pyproject.toml: pyrage>=1.2 を deps、pytest>=8.0 を dev group、tool.pytest.ini_options 追加
- tests/env, tests/cli: ラウンドトリップ + 異常系 28 件

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(env): レビュー指摘の修正 (storage/bundle/cipher)

- storage.py: LocalBackend で file:// URI を url2pathname で実パスへ変換
- bundle.py: manifest.files の要素型 (dict, path: str, sha256: str) を検証
- cipher.py: age 秘密鍵判定をバイト列で行い、UTF-8 デコード失敗を明示エラー化

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(env): round 2 レビュー指摘の修正 (堅牢性 + test 追加)

- storage: file:// URI の netloc が空/localhost 以外なら StorageError で拒否 (codex major)
- bundle: tar 内の重複エントリを BundleError で検出 (codex major)
- cipher: _resolve_recipient の @path 再帰に深さ制限 (上限 5) を追加 (gemini minor)
- tests/storage: file:// URI roundtrip と remote host 拒否の test を追加 (gemini minor)
- tests/bundle: _validate_manifest 不正系 (files が list でない / entry が dict でない /
  path 不正 / sha256 不正) + 重複エントリの test を追加 (gemini minor)
- tests/cipher: @path 循環参照で CipherError を返す test を追加 (gemini minor)

* fix(env): sha256 必須化と ed25519 デフォルト鍵対応 (round 3)

- bundle.py: manifest.files[*].sha256 を必須の 64 文字 16 進文字列として検証
  None / 欠落 / 長さ違い / 非 16 進は BundleError。完全性チェック迂回を防止
- cipher.py: default_recipient_paths / default_identity_paths に
  ed25519 (id_ed25519.pub / id_ed25519) を追加し、rsa より優先
- tests: sha256 欠落 / None / 長さ違い / 非 16 進の異常系テストを追加
- tests: ed25519 がデフォルトパス候補に含まれ rsa より優先されることを検証

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(env): round 4 レビュー指摘の修正 (異常系の堅牢化)

- bundle: yaml.safe_load の結果が dict でない場合に BundleError を送出
  (top-level が list/str/数値の場合に AttributeError が漏れるのを防止)
- cipher: @path 参照ファイルが UTF-8 でない場合 CipherError に包んで再送出
  (UnicodeDecodeError が呼び出し側に漏れていた)
- storage.resolve: Windows ドライブレター (C:\path 等) を urlparse が
  scheme と誤認する問題に対応し LocalBackend にフォールバック

各修正に対応する異常系 test を追加 (合計 +5 test)。

* fix(env): _resolve_identity の OSError を CipherError に包む (round 5)

- lib/devbase/env/cipher.py: path.read_bytes() を try/except OSError で
  ラップし、I/O エラー時も CipherError で統一されたエラー型を返す
- tests/env/test_cipher.py: monkeypatch で read_bytes に OSError を
  発生させて CipherError に包まれることを検証する test を追加

gemini round 5 指摘 (minor / 堅牢性) に対応。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(env): round 6 レビュー指摘の修正 (決定性 + 完全性 + 堅牢性)

- bundle.pack: gzip.GzipFile(mtime=0) でラップし出力を完全に決定的にする
- bundle._validate_manifest: tar 内ファイルセットと manifest の完全一致を
  検証し、manifest に記載のない未知ファイルを BundleError で拒否する
- cipher._resolve_recipient: @path の read_text で発生する OSError を
  CipherError に包んで一貫したエラーハンドリングにする
- cipher._resolve_identity: OpenSSH ヘッダで先に SSH 鍵を判別する分岐を
  追加し、鍵形式判別を明示化 (将来の形式追加もしやすくする)
- tests: pack 決定性 / unknown file 拒否 / @path OSError ラップ /
  OpenSSH ヘッダ優先判別の test を追加

* fix(env): @path 参照ファイルのコメント・空行をスキップする (round 6 追加)

recipient ファイルにコメント (# 始まり) や空行が混在していても扱えるよう、
有効な最初の行のみを採用する。テストも追加。

* fix(env): round 1 レビュー指摘の修正 (TOCTOU + BundleError 統一 + prefix 互換 + completion)

- storage.py: LocalBackend.write_bytes を os.open(mode=0o600, O_CREAT|O_TRUNC|O_WRONLY) で
  作成時点から 0600 を強制し、umask に依らない TOCTOU 安全な書き込みに変更
  (codex major / gemini minor — 同一指摘)。既存ファイル上書き時も先に chmod で権限を絞る。
  read_bytes / write_bytes の OSError を StorageError にラップ (gemini minor)。
- bundle.py: unpack() の tarfile.open / getmembers / extractfile で発生する
  tarfile.TarError / OSError を BundleError にラップ (gemini major)。
  make_entries_from_disk の exists() を is_file() に変更し、対象パスが
  ディレクトリだった場合の IsADirectoryError を防止 (gemini minor)。
  _validate_manifest に manifest.files の path 重複検出を追加 (codex minor)。
- cli.py: SUBCMD_PREFIX_PREFERENCES を追加し、`devbase env e` が引き続き edit に
  解決されるように prefix 解決の後方互換を維持 (codex minor)。
- etc/devbase-completion.bash, etc/_devbase: env export サブコマンドと
  各オプションを補完に追加 (codex minor)。
- tests: storage の TOCTOU / OSError ラップ / 既存ファイル 0600 上書き、
  bundle の path 重複 / 壊れた tar / is_file 切替、CLI prefix の後方互換テストを追加。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(env): round 2 advisory レビュー指摘の修正 (docstring / help / stdin prompt)

- io_export.py: `_resolve_recipients` の docstring を更新し、既定鍵が
  `id_ed25519.pub` → `id_rsa.pub` の優先順で探索される実態に合わせる
- cli.py: `--recipient` の help を `Default: ~/.ssh/id_ed25519.pub, then
  ~/.ssh/id_rsa.pub (first existing one)` に修正
- io_export.py: `--passphrase-stdin` で `sys.stdin.isatty()` の場合に
  `passphrase: ` プロンプトを stderr に表示し、対話実行時のハング感を解消
- 暗号化キー未指定エラーメッセージも ed25519 優先を反映
- tests/cli/test_env_export.py: tty / 非 tty 双方の挙動を検証する 2 ケース追加

Refs: PR #14 review comments 3280597873 / 3280597877 / 3280597881

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: PLAN03-1 PR2 Draft PR 作成 (import 実装)

* feat(env): PLAN03-1 PR2 devbase env import (Local + Stdio)

`devbase env import` を追加し、export で生成したバンドル (age 暗号化済み or
平文 tar.gz) から `.env` 群を復元できるようにする。

主な機能:
- merge セマンティクス: --merge keep-existing (既定) / prefer-incoming
- --replace-keys: 指定キーのみ上書き
- --replace: 対象 .env を丸ごと差し替え (backup 取得)
- --dry-run: 差分プレビュー (書き込みなし)
- 2 フェーズ書き出し (prepare → commit) で部分適用を最小化、失敗時は
  backup から best-effort で rollback
- --backup-dir / --keep-last N (既定 10) で backup を GC
- .env.sources.yml は既定で上書きせず参照用コピーのみ、--merge-metadata で
  新規 source エントリのみ追加、--no-metadata で完全無視
- 暗号化判定: gzip magic で平文 / age 暗号化を識別
- 引数バリデーション: SOURCE='-' と --passphrase-stdin の併用拒否、
  --passphrase-env と --passphrase-stdin の併用拒否、--replace と
  --replace-keys の併用拒否
- 既定 identity: ~/.ssh/id_ed25519 → ~/.ssh/id_rsa の順で探索

テスト (tests/cli/test_env_import.py, 17 ケース):
- export → import の round-trip、0600 permission 保持、LF 保持
- merge モード別の挙動 (keep-existing / prefer-incoming / replace-keys /
  replace)、dry-run が変更しないこと、replace 時の backup 作成
- --include-project / --no-metadata / --merge-metadata の挙動
- 未知 manifest version のバンドル拒否、--keep-last による古い backup GC
- 平文バンドル import、passphrase round-trip

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(env): import の merge/rollback/GC 安全性を改善

cross-review round 1 で指摘されたデータ損失・部分適用・merge 不整合を修正:

- --replace-keys 指定外でも既存に無い incoming 新規キーは追加するように修正
  (CLI help の "other keys behave like keep-existing" に整合)
- _rollback で op='create' なターゲットは backup が無くても unlink し、
  commit 途中失敗時の部分適用残骸を消す
- _gc_backups は devbase が生成する timestamp 形式 (YYYYMMDD-HHMMSS) の
  ディレクトリのみを削除対象にし、--backup-dir 親に置かれた無関係なファイル
  /ディレクトリを誤って消さないようにする
- EnvFile.parse_bytes(data) を新設し、io_import の bytes パースを
  一時ファイル経由から直接パースに置き換え (I/O 削減 + 例外安全)
- 上記 3 件分の回帰テストを追加

Refs: codex review #4336666744 / gemini review #4336672519

* fix(env): import の二重エスケープ / rollback / tmp 残骸 / completion を修正

PR #15 round 2 のクロスレビュー指摘 (codex 3 件 + gemini 2 件) に対応。

* EnvFile.parse_bytes に double-quote 内 escape の逆変換 (state machine) を
  追加し、save / _format_env_bytes との round-trip を成立させる。これにより
  backslash / quote / 改行を含む値が import → export を経て二重エスケープ
  される問題を解消する。
* _plan_env_merge の create パスでは _format_env_bytes による再シリアライズを
  避け、incoming_bytes をそのまま採用する。バンドル側のバイト列を完全に
  保持し、parse/format に潜む副作用を確実に排除する。
* _rollback で「backup が無い = 元ファイル不在」のケースを op に関係なく
  unlink するように変更。op='sources-merge' で sources.yml を新規作成した
  ロールバックでも残骸が残らなくなる。
* _commit 失敗時に、まだ rename されていない .import.tmp ファイルを
  try-finally で確実に削除する。
* _make_backup_dir に microsecond + 連番フォールバックを付与し、同一秒内に
  import が複数回走っても backup ディレクトリが衝突しない。
* etc/devbase-completion.bash と etc/_devbase の env サブコマンド一覧に
  import を追加し、各オプションも補完できるようにする。

テストでは parse_bytes round-trip / create 経路の byte preservation /
sources.yml rollback unlink / tmp cleanup / backup 衝突回避を検証する。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(env): import の merge でコメントを保持し $ をエスケープする

- ``EnvFile.parse_entries`` を追加し、コメント / 空行 / kv を行単位で
  トークン化できるようにする (PR #15 gemini 指摘)。
- ``_merge_into_existing_bytes`` で既存 ``.env`` のコメント・空行・キー順を
  保持したまま値を差し替えるよう merge 経路を変更。``EnvFile.dump_bytes``
  への単純差し替えではコメントが失われていた。
- ダブルクオート値に含まれる ``$`` を ``\$`` にエスケープし、``parse_bytes``
  側でも ``\$`` を ``$`` に戻すように round-trip 対応 (シェル ``source`` 時の
  意図しない変数展開を防止 / PR #15 gemini 指摘)。
- ``EnvFile.dump_bytes`` / ``EnvFile.dump_entries_bytes`` にフォーマット
  ロジックを集約し、``io_import._format_env_bytes`` を廃止。``EnvFile.save``
  も ``dump_bytes`` 経由に統一して二重実装を解消 (PR #15 gemini 指摘)。
- 新規テスト: コメント保持マージ (prefer-incoming / keep-existing)、
  ``$`` の round-trip、``EnvFile.dump_bytes`` のエスケープ仕様を追加。

* refactor(env): _plan_env_merge の重複を _build_merge_plan に共通化

各マージ戦略 (replace_keys / keep-existing / prefer-incoming) で個別に書かれていた
new_bytes 生成と _Plan 構築を `_build_merge_plan` にまとめた。各分岐は
merged / added / overwritten / skipped の計算だけを担当するように整理。
動作変更なし (PR #15 gemini round4 指摘 / 既存テスト 102 件 PASS)。

* fix(env): コメントのみ既存 .env を merge 経路に通す + env i 短縮維持

- _build_merge_plan / _plan_env_merge / replace ブランチの op 判定を
  existing (key=value dict) の有無から target.exists() に変更。
  コメント / 空行のみで構成された既存 .env が「create」と誤判定されて
  incoming_bytes で上書きされ既存コメントが失われる問題を修正
  (PR #15 round5 codex/gemini 指摘)。
- SUBCMD_PREFIX_PREFERENCES に i: init を追加。import 追加で
  devbase env i が init / import の両方にマッチして ambiguous に
  なっていたため、既存ショートカット (devbase env i → init) を維持する
  (PR #15 round5 codex 指摘)。
- 回帰テスト追加:
  - tests/cli/test_env_import.py: コメント/空行のみの既存 .env が
    prefer-incoming / keep-existing / replace-keys でコメント保持される
    こと。--replace ブランチでも op='replace' として報告され backup が
    取られること
  - tests/cli/test_prefix_resolution.py: devbase env i → init,
    devbase env im → import, devbase env in → init

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(env): import の sys を先頭 import に / TTY 時 getpass.getpass でエコー抑止

gemini round 6 で挙がった minor 指摘 2 件に対応:

1. lib/devbase/env/io_import.py:97 `import sys` がローカル import になっていたのを
   ファイル先頭の標準 import セクションに移動 (`import getpass` も同時追加)。
   メンテナンス性向上、PEP 8 への準拠。
2. lib/devbase/env/io_import.py:100 TTY 入力時に `sys.stdin.readline()` を使って
   いたため、パスフレーズがそのまま画面にエコーバックされていた。
   `getpass.getpass(prompt, stream=sys.stderr)` を使うように変更。
   パイプ入力時 (非 TTY) は従来どおり `sys.stdin.readline()` で読み (
   stdin リダイレクト経由のテストフックとして必要)、getpass の EOFError は
   `ImportError("stdin からパスフレーズを読み取れませんでした")` に変換する。

回帰テスト 3 件を追加 (tests/cli/test_env_import.py):
- test_read_passphrase_uses_getpass_on_tty
- test_read_passphrase_falls_back_to_stdin_on_pipe
- test_read_passphrase_tty_eof_raises_import_error

ローカル品質チェック:
- uv run pytest tests/ -> 112 passed (107 + 3 + 既存 review round で +2)
- uv run python -m compileall lib bin -> OK
- uvx ruff check --select=E9,F63,F7,F82 lib -> All checks passed

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(env): export 側にも TTY エコー抑止と先頭 import を適用 (import と対称化)

PR #15 round 7 で io_import.py に入れた修正 (commit 454425e) を、対称な
io_export.py 側にも適用する。両者は _read_passphrase の構造が同じで、
TTY エコー抑止と import 位置の問題も同じパターンで残っていた。

- lib/devbase/env/io_export.py:
  - ローカル `import sys` をファイル先頭の標準 import セクションに移動
  - `import getpass` を追加
  - TTY 入力時に `getpass.getpass("passphrase: ", stream=sys.stderr)` を
    使うように変更。EOFError は ExportError("stdin からパスフレーズを
    読み取れませんでした") に変換
  - パイプ入力時 (非 TTY) は従来どおり `sys.stdin.readline()` を使用

- tests/cli/test_env_export.py:
  - 旧テスト test_read_passphrase_shows_prompt_on_tty /
    test_read_passphrase_no_prompt_on_pipe は `print` 経由の prompt 表示を
    検証する構造だったため、getpass.getpass をモックする方式に書き換え
  - 追加: test_read_passphrase_uses_getpass_on_tty (TTY 時に
    getpass.getpass が呼ばれ stdin は消費されないこと)
  - 追加: test_read_passphrase_falls_back_to_stdin_on_pipe (非 TTY では
    getpass は呼ばれず stdin.readline 経路に入ること)
  - 追加: test_read_passphrase_tty_eof_raises_export_error (EOFError →
    ExportError 変換)

ローカル品質チェック:
- uv run pytest tests/ -> 113 passed
- uvx ruff check --select=E9,F63,F7,F82 lib -> All checks passed

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(env): _unescape_double_quoted を re.sub + 逆引き辞書に書き換え

PR #15 ユーザーレビュー (lib/devbase/env/store.py:160) で挙がった
「pythonic に書き直してください」への対応。

before: バックスラッシュ + 次文字を 1 文字ずつ走査する hand-rolled
state machine (25 行)。
after:  re.compile(r'\\.') で \<char> を一括捕捉し、逆引き辞書
{'\\\\': '\\', '\\"': '"', '\\n': '\n', '\\$': '$'} で 1 行置換する
re.sub 呼び出し 1 つに集約 (5 行)。

挙動は state machine と完全に同等:
- 未知エスケープ (例: \x) は dict.get の default にマッチ文字列
  自身を返すことでバックスラッシュごと保持
- 末尾の単独 \ は \\. のドットが 2 文字目を要求するため自然に
  マッチせず、そのまま保持
- \\\\n (リテラル \\ + n) と \\n (改行) も re.sub は左から非重複で
  マッチするため state machine と同じ結果

差分: 43 → 15 行 (net -28 行 削除 / +15 行 追加 = -13 行)。
既存テスト (test_envfile_parse_bytes_round_trip_with_escapes 等) で
double-quote escape の round-trip は完全に検証済みのため新規テストは追加せず。

ローカル品質チェック:
- uv run pytest tests/ -> 113 passed
- uvx ruff check --select=E9,F63,F7,F82 lib -> All checks passed
- uv run python -m compileall lib bin -> OK

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
takemi-ohama and others added 3 commits May 23, 2026 20:57
* chore: PLAN03-1 PR3 Draft PR 作成 (S3 backend)

* feat(env): PLAN03-1 PR3 devbase env export/import S3 backend

- `s3://bucket/key` を `devbase env export` / `devbase env import` の
  入出力先として指定できるようにする
- export 時は ServerSideEncryption (`aws:kms` 既定, `AES256` 切替可) を
  常に PutObject に付与し、加えて GetBucketEncryption で **バケット側の
  既定暗号化** も事前確認する
- 暗号化未設定 / 確認不可 (AccessDenied) のバケットへは
  `--unsafe-allow-unencrypted-bucket` を明示しない限り export を拒否する
  (オブジェクト単位の SSE はこのフラグに関係なく常に付与される)
- SSE 種別 / KMS 鍵 / エンドポイント / リージョンは環境変数
  (`DEVBASE_S3_SSE`, `DEVBASE_S3_SSE_KMS_KEY_ID`,
  `DEVBASE_S3_ENDPOINT_URL`, `DEVBASE_S3_REGION`) で上書きできる
- `boto3` は optional dep として `[project.optional-dependencies] s3`
  に追加 (`pip install 'devbase[s3]'` でインストール)
- `gs://` (GCS) は PLAN03-1 PR4 廃案のため明示エラーで拒否する

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(env): PLAN03-1 PR3 storage.py minor 修正 (cross-review round 1)

PR #19 のクロスレビュー (codex / gemini) で指摘された minor 3 件に対応。

- `_parse_s3_uri`: `urlparse` は S3 キーに含まれる `?` / `#` を query /
  fragment として落としてしまうため、AWS CLI と同じ挙動になるよう
  スキームを除去した上で `partition('/')` で分割する。
- boto3 未インストール時のエラーメッセージを `pip install boto3` から
  本プロジェクトの optional dependency 経由
  (`pip install 'devbase[s3]'` / `uv add 'devbase[s3]'`) に変更。
- `_verify_bucket_encryption`: MinIO / LocalStack 等の S3 互換ストレージで
  GetBucketEncryption が NotImplemented を返すケースに備え、
  `--unsafe-allow-unencrypted-bucket` 指定時は未知エラーも警告のみで続行する
  逃げ道として機能させる (CHANGELOG の S3 互換ストレージ対応との整合)。

新規テスト: query/fragment 保持、未知エラーの拒否、unsafe フラグでの続行を追加。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(env): PLAN03-1 PR3 boto3 を main dependency に昇格

boto3 を `[project.optional-dependencies].s3` から `[project].dependencies`
に移し、ImportError ハンドラとフォローアップ案内文を撤去する。

意図:
- S3 URI を初めて指定したユーザに `pip install 'devbase[s3]'` を
  打たせる UX を廃する。25MB 程度のコスト増 (botocore 24MB) は
  実装複雑度ゼロと引き換え。
- 引数検出 (`s3://` 走査) や lazy 自動 install を採らないのは、
  CI / オフライン / read-only コンテナで挙動が安定するため。

storage.py / test_storage.py の boto3-missing 関連コードを削除。
CHANGELOG.md の optional 記述も同期更新。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(env): PLAN03-1 PR5 env export/import モジュールを整理

- 711 行に肥大化していた `io_import.py` を以下の 3 モジュールに分割する。
  公開 API (`ImportOptions`, `import_bundle`, `ImportError`) は維持し、
  テストの `_read_passphrase` 直接 import や `getpass` パッチも継続して動く:
  - `io_import.py` (209 行): 引数検証 / 復号判定 / 全体オーケストレーション
  - `_import_merge.py`: `Plan` データクラス、`plan_env_merge` / `plan_sources`、
    既存コメント・空行を保持した merge ロジック、ログ整形
  - `_import_atomic.py`: 2 フェーズ書き込み (`backup_existing` → `write_atomic`
    → `commit`)、`gc_backups`、ロールバック

- export / import で重複していた共通 helper を `io_common.py` に集約する:
  - `read_passphrase()` — env / stdin 入力、tty 時の getpass エコー抑止
  - `resolve_recipient_specs()` / `resolve_identity_specs()` — 省略時の
    `~/.ssh/id_ed25519(.pub)` → `id_rsa(.pub)` fallback
  - `write_secure_bytes()` — `os.open(O_WRONLY|O_CREAT|O_TRUNC, mode=0o600)` で
    TOCTOU を避けてセキュアにバイト列を書き出す共通実装。`storage.LocalBackend`
    と `_import_atomic` から呼び出す

- `_plan_env_merge()` の 4 段ネスト if/elif を 4 つの小さな関数
  (`_plan_replace` / `_plan_replace_keys` / `_plan_keep_existing`
  / `_plan_prefer_incoming`) に分割し、`plan_env_merge` 本体はモード選択のみに
  簡素化する

- `storage.LocalBackend.write_bytes` を `io_common.write_secure_bytes`
  への薄いラッパに置き換え、重複していた `os.open` + chmod パターンを削除

- `io_export.py` (185 → 168 行) の `_read_passphrase` / `_resolve_recipients`
  は `io_common` への delegation に置き換え、`encrypt_payload` / `validate_options`
  の helper 関数に export 本体のロジックを分解

- `_commit()` 移動に伴い、テスト 3 件の `monkeypatch.setattr(_io_import.os, 'replace', ...)`
  パッチ先を `_import_atomic.os` に更新。`log_plans` 移動に伴う caplog の
  logger 名も `devbase.env._import_merge` に追従

リファクタの動機:
- io_import.py が PR1〜PR3 を通じて 711 行まで肥大化し、merge 計画 / atomic 書き込み
  / orchestration が同居して読みづらかった
- io_export と io_import で `_read_passphrase` / 既定鍵 fallback / セキュア書き込みが
  ほぼ重複していた

挙動の変更は無く、全 136 テストが引き続き green を維持する。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(env): PLAN03-1 PR5 env export/import 利用者向けガイドを追加

`docs/user/env-export-import.md` (456 行) を新設し、以下を網羅する:

- 対象ファイル一覧 (global / projects/*/.env / .env.sources.yml) と
  公開可能な雛形 (`projects/*/env`) を含めない設計理由
- クイックスタート (既定鍵での export → 別マシンでの import)
- バンドル構造 (manifest.yml の sha256 検証、version 互換ポリシー)
- age 暗号化: recipient / identity / passphrase の 3 方式、対応鍵種別表、
  ssh-ecdsa 非対応への対処 (`age-keygen` / `ssh-keygen -t ed25519`)、
  既定鍵 (`~/.ssh/id_ed25519` → `id_rsa`)
- 入出力先: ローカル / stdio / S3。S3 の SSE 強制と
  `--unsafe-allow-unencrypted-bucket`、`DEVBASE_S3_*` 環境変数
- export / import の全オプション表、merge モード (keep-existing /
  prefer-incoming / --replace-keys / --replace) の動作比較
- `.env.sources.yml` の取り扱い (既定スキップ + 参照用コピー、
  `--merge-metadata` での新規エントリ追加)
- 2 フェーズ書き込み + backup + ロールバックの仕組み、
  `--keep-last N` での GC、ACID 非保証の注意
- 典型ワークフロー 4 件 (別マシン移行 / 定期バックアップ / S3 チーム共有 / CI 配布)
- トラブルシューティング 8 件

加えて以下のリンクを追加:
- `README.md`: 「利用者向け」ドキュメント表に env-export-import.md への
  リンクを追加し、`env` グループの説明に export / import を併記
- `docs/user/environment-variables.md`: 「別マシンへの移行 / バックアップ」
  節を新設して env-export-import.md へ誘導、ベストプラクティスに追記
- `CHANGELOG.md` (Unreleased): docs 追加とリファクタリングを記載

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(env): warning ログの文面矛盾を解消 (unsafe フラグ続行時)

`_verify_bucket_encryption` で `--unsafe-allow-unencrypted-bucket` 指定時に
「export を中止します。(unsafe フラグにより続行)」という矛盾した警告が出ていた。
問題説明 (problem) と対処案内 (guidance) を分離し、warning は problem のみ、
StorageError raise 時は問題+対処案内を出すよう統一。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@takemi-ohama takemi-ohama changed the title release: PLAN03-1 devbase env export / import release: PLAN03-1 devbase env export / import + S3 backend May 23, 2026
Copy link
Copy Markdown
Contributor Author

@takemi-ohama takemi-ohama left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 cross-review | round 1 | codex | REQUEST_CHANGES

merge/import の整合性に関わる修正が必要です。既存 .env の未変更行を破壊しないこと、manifest 済みだが未対応の bundle エントリを成功扱いで落とさないことを担保してください.

Comment thread lib/devbase/env/_import_merge.py Outdated
Comment thread lib/devbase/env/_import_merge.py Outdated
Copy link
Copy Markdown
Contributor Author

@takemi-ohama takemi-ohama left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 cross-review | round 1 | gemini | COMMENT

age-keygen コマンドで生成した標準的な秘密鍵ファイルのフォーマットに対する考慮漏れ(コメント行)が見つかりました。修正をお願いします。

Comment thread lib/devbase/env/cipher.py Outdated
Copy link
Copy Markdown
Contributor Author

@takemi-ohama takemi-ohama left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 cross-review | round 1 | gemini | APPROVE

特に指摘事項はありません。

- _import_merge: 未対応 arcname を黙って捨てず MergeError で停止
  (filter_members の logger.debug+continue → raise MergeError)
- _import_merge: merge 経路で値が変更されていないキーは raw 行を温存し、
  PATH=$HOME/bin のような未クオート値が PATH="\$HOME/bin" に勝手に
  エスケープされて source 時の意味が変わるのを防ぐ
- cipher: age-keygen 出力の先頭コメント (# created / # public key) を
  考慮し、行単位でコメント / 空行を除いて AGE-SECRET-KEY-1 行を検出
- 上記 3 件に対する回帰テストを追加

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@takemi-ohama
Copy link
Copy Markdown
Contributor Author

PR #13 round1 cross-review 対応サマリ

commit: c4c4bca

対応件数

  • major: 3 件 (全件修正)
  • minor: 0 件
  • nit: 0 件
  • deferred: 0 件
  • rejected: 0 件

修正内容

  1. [major / 安全性] _import_merge.filter_members — 未対応 arcname の silent drop

    • bundle._validate_manifest は path パターンを制限していないため、未対応 arcname を logger.debug + continue で捨てると manifest 記載と適用結果が食い違うリスクがあった。MergeError で明示的に停止するように変更。
    • 回帰テスト追加: test_env_import_filter_members_rejects_unknown_arcname
  2. [major / 正確性] _import_merge._merge_into_existing_bytes — 未クオート値の意図しない再エスケープ

    • PATH=$HOME/bin のような未クオート値が、merge 経路で全 kv エントリを _format_kv_line で再フォーマットすることにより PATH="\$HOME/bin" に変わり、シェル source 時の意味が変わっていた。値が変更されていないキーは元の raw 行を温存するように修正。
    • 回帰テスト追加: test_env_import_merge_preserves_raw_unchanged_unquoted_dollar
  3. [major / 正確性] cipher._resolve_identity — age-keygen 出力のコメント考慮漏れ

    • raw.strip().startswith(b'AGE-SECRET-KEY-1') では age-keygen が出力する # created: ... / # public key: ... のヘッダ付きファイルで False になり、誤って OpenSSH 経路にフォールスルーしていた。_resolve_recipient と同様に行単位でコメント / 空行を除いてから AGE-SECRET-KEY-1 行を検出するように修正。
    • 回帰テスト追加: test_resolve_identity_accepts_age_keygen_output_with_comments

品質チェック

  • uv run pytest -q: 139 passed (うち新規 3 件)
  • uvx ruff check --select=E9,F63,F7,F82 lib: All checks passed
  • CI (push 前時点): Python syntax check (3.10/3.11/3.12), Ruff lint, ShellCheck すべて SUCCESS

解決済みスレッド

  • PRRT_kwDOSHX-a86EUl0K (_import_merge.py:117)
  • PRRT_kwDOSHX-a86EUl0L (_import_merge.py:89)
  • PRRT_kwDOSHX-a86EUmC0 (cipher.py:108)

Copy link
Copy Markdown
Contributor Author

@takemi-ohama takemi-ohama left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 cross-review | round 2 | codex | APPROVE

修正必須の指摘はありません。

Copy link
Copy Markdown
Contributor Author

@takemi-ohama takemi-ohama left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 cross-review | round 2 | gemini | REQUEST_CHANGES

export 時のパイプ運用を阻害する論理エラーと、堅牢性の改善点が1点ずつあります。パイプラインでの運用に影響するため修正を依頼します。

Comment thread lib/devbase/env/io_export.py Outdated
Comment thread lib/devbase/env/io_common.py Outdated
- io_export._validate_options: DEST='-' (stdout) と --passphrase-stdin の
  排他チェックを削除。stdin (passphrase) と stdout (bundle) は別ストリーム
  のため衝突しない (例: `echo pass | devbase env export - --passphrase-stdin > out`)
- io_common.read_passphrase: stdin から読んだ行末を rstrip('\n') から
  rstrip('\r\n') に変更。Windows/WSL 由来 CRLF パイプで末尾 \r が残ると
  age 復号が無音で失敗するため。
- tests: 排他テストを併用許可テストに置換 + CRLF rstrip 回帰テスト追加

Refs: #13 (review)
@takemi-ohama
Copy link
Copy Markdown
Contributor Author

/ndf:fix round 2 対応サマリ

commit: 8f8f5c3

severity 件数 対応
critical 0
major 1 fix
minor 1 fix
nit 0
deferred 0
rejected 0

修正内容

1. [major / 論理エラー] lib/devbase/env/io_export.py:78 (gemini 指摘)

  • DEST='-' (stdout) と --passphrase-stdin の排他チェックを削除
  • stdin (passphrase) と stdout (bundle) は別 fd で衝突しないため適法
  • echo "pass" | devbase env export - --passphrase-stdin > out のパイプ運用が可能に
  • io_import 側は両方 stdin を使うため排他チェック維持
  • 回帰テスト: test_export_allows_stdout_with_passphrase_stdin

2. [minor / 堅牢性] lib/devbase/env/io_common.py:49 (gemini 指摘)

  • rstrip('\n')rstrip('\r\n') に変更
  • Windows/WSL の CRLF パイプで末尾 \r がパスフレーズに混入する問題を防止
  • 回帰テスト: test_read_passphrase_strips_crlf_from_pipe

検証

  • uv run pytest -q 140 passed
  • ruff check --select=E9,F63,F7,F82 lib All checks passed

codex round 2

  • intent=APPROVE / 0 件 — round1 修正で納得済。追加対応なし。

Copy link
Copy Markdown
Contributor Author

@takemi-ohama takemi-ohama left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 cross-review | round 3 | codex | REQUEST_CHANGES

バンドル内パスの正規化で想定外の import 先が発生するため、修正が必要です。

Comment thread lib/devbase/env/_import_merge.py Outdated
Comment thread etc/_devbase Outdated
Copy link
Copy Markdown
Contributor Author

@takemi-ohama takemi-ohama left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 cross-review | round 3 | gemini | APPROVE

S3 連携およびエクスポート/インポートロジックのモジュール分割の実装は適切に行われています。懸念される競合状態(atomicな書き込み・バックアップ時の連番付与)や boto3 の遅延インポート、AWSエラーへの対応も的確に実装・テストされており、素晴らしい設計です。修正事項はありません。

- _import_merge.py の project 名正規表現を厳格化し、`./..`/隠しディレクトリを
  弾く (path traversal: `env/projects/./.env` が `$DEVBASE_ROOT/projects/.env`
  に解決される問題への対策)。
- 上記の回帰テストを `tests/cli/test_env_import.py` に追加。
- zsh/bash 補完に `--unsafe-allow-unencrypted-bucket` (env export) を追加し
  CLI 定義と同期。
@takemi-ohama
Copy link
Copy Markdown
Contributor Author

/ndf:fix round 3 対応サマリ

commit: cf002090ee48dd8b07f9143f4fb88b9ed1abc260

対応 (2件)

重要度 カテゴリ path 概要
major 正確性 / 安全性 lib/devbase/env/_import_merge.py:26 _PROJECT_ENV_RE を厳格化し、env/projects/./.env のような特殊セグメントを拒否 (path traversal 対策)。回帰テスト 5 件追加
minor 保守性 etc/_devbase:165 / etc/devbase-completion.bash:86 zsh/bash 補完に --unsafe-allow-unencrypted-bucket を追加し CLI と同期

重要度別件数

  • critical: 0
  • major: 1
  • minor: 1
  • nit: 0
  • deferred: 0
  • rejected: 0

CI

全 5 checks GREEN (Ruff lint / Python syntax 3.10-3.12 / ShellCheck) — push 前スナップショット時点。

テスト

  • uv run pytest -q → 145 passed
  • 追加テスト: test_env_import_rejects_unsafe_project_names (. / .. / .hidden / 空白を parametrized)、test_env_import_accepts_normal_project_names

解決済みスレッド

  • PRRT_kwDOSHX-a86EUsQZ (3293231951) — path traversal
  • PRRT_kwDOSHX-a86EUsQa (3293231953) — completion

再レビューお願いします。

Copy link
Copy Markdown
Contributor Author

@takemi-ohama takemi-ohama left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 cross-review | round 4 | codex | APPROVE

修正必須の指摘はありません。

Copy link
Copy Markdown
Contributor Author

@takemi-ohama takemi-ohama left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 cross-review | round 4 | gemini | REQUEST_CHANGES

  • 暗号化(age)の recipient 解析と identity のデフォルト解決において、複数キーの取り扱いに漏れがあり、チーム運用時や複数鍵所持者の復号時に意図せぬ動作となる箇所が見受けられます。
  • cipher.pyio_common.py の 2 箇所について対応をお願いします。

Comment thread lib/devbase/env/cipher.py
Comment thread lib/devbase/env/io_common.py Outdated
- cipher.py: `@PATH` ファイルに複数行の鍵が含まれる場合は明示的に
  CipherError を投げる。team_keys.txt のような複数公開鍵列挙を
  暗黙に「最初の 1 人」だけ扱う挙動は、チーム運用で暗号化バンドル
  が壊れる原因になるため誤運用を防ぐ
- io_common.py: `resolve_identity_specs` で `~/.ssh/id_ed25519` と
  `~/.ssh/id_rsa` の両方が存在する場合、両方を返すように変更。
  `pyrage.decrypt` は複数 identity を受け付け、バンドルに合致する
  鍵だけが使われる。これにより RSA で暗号化されたバンドルを
  ed25519 鍵だけで開けず失敗する問題を解消
- resolve_recipient_specs は意図的に最初の 1 つを返す挙動を維持
  (どの鍵で暗号化するか一意に決める必要があるため)
- 複数鍵ファイル拒否と複数 identity 復号の回帰テストを追加
@takemi-ohama
Copy link
Copy Markdown
Contributor Author

round 4 fix サマリ (gemini 指摘対応)

  • fix commit: e7b1464
  • 対応: 2 件 (major 2 / minor 0 / nit 0)
  • deferred: 0 件
  • rejected: 0 件

修正内容

# path severity 内容
1 lib/devbase/env/cipher.py:60- major @PATH ファイルが複数鍵を含む場合に明示的に CipherError で拒否 (誤運用防止)
2 lib/devbase/env/io_common.py:70- major resolve_identity_specs が存在するすべての既定 ssh 鍵を返すよう変更。pyrage.decrypt の複数 identity 仕様を活かして、ed25519 / rsa どちらで暗号化されたバンドルでも復号できるようにする。resolve_recipient_specs (暗号化側) は意図的に最初の 1 つに留め、非対称性を docstring に明記

回帰テスト

  • tests/env/test_cipher.py::test_resolve_recipient_at_path_rejects_multiple_keys 追加
  • tests/env/test_io_common.py 新規作成 (8 テスト):
    • resolve_identity_specs の挙動 (両方/片方/明示/空)
    • resolve_recipient_specs の挙動 (最初の 1 つを返す)
    • 両 identity を渡したときに正しい鍵が選ばれて復号される end-to-end テスト

ローカルテスト結果

  • uv run pytest -q: 154 passed (前ラウンド 145 → 9 件追加でも全 green)
  • CI (前ラウンド): 全 5 ジョブ SUCCESS。push 後の CI 結果は本コメント時点で未到着。

両 review thread を resolve 済み。再レビューお願いします。

Copy link
Copy Markdown
Contributor Author

@takemi-ohama takemi-ohama left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 cross-review | round 5 | codex | REQUEST_CHANGES

export/import で許容する project 名の集合を揃えて、export で生成した bundle が import 側で拒否されないようにしてください。

Comment thread lib/devbase/env/bundle.py
Copy link
Copy Markdown
Contributor Author

@takemi-ohama takemi-ohama left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 cross-review | round 5 | gemini | APPROVE

S3バックエンドの抽象化や age を用いた暗号化処理、2フェーズのアトミックな書き込み・ロールバック機能など、全体を通してセキュアで堅牢な実装が行われていることを確認しました。

unsafe-allow-unencrypted-bucket 利用時のフォールバック処理について1点のみ、エラーハンドリングのユーザビリティ改善の提案を残しています。

Comment thread lib/devbase/env/storage.py
- bundle.is_valid_project_name() を導入し、export 側
  (make_entries_from_disk) でも import 側 (_PROJECT_ENV_RE) と同じ
  project 名 validator を適用する。空白 / 先頭 `.` / `-` 等を含む
  ディレクトリは警告のみで skip し、round-trip 不能な bundle が
  作られるのを防ぐ (codex round 5 指摘)。
- _import_merge._PROJECT_ENV_RE を bundle._VALID_PROJECT_NAME_RE
  から組み立てるよう変更し、import / export 両側の validator を
  契約レベルで同期させる。
- S3Backend._verify_bucket_encryption で NoSuchBucket / 認証・接続
  系エラー (code が取れないケース) は --unsafe-allow-unencrypted-bucket
  の有無に関わらず即 StorageError を投げる。続行しても put_object が
  同じエラーで再失敗するだけのため、早期にエラーを返してユーザの
  トラブルシューティングを助ける (gemini round 5 指摘)。
- 回帰テスト追加:
  - test_is_valid_project_name / test_make_entries_from_disk_skips_invalid_project_names
  - test_make_entries_from_disk_invalid_name_explicitly_included_is_still_skipped
  - test_make_entries_from_disk_validator_matches_import_side (契約同期)
  - test_s3_backend_rejects_no_such_bucket_even_with_unsafe_flag
  - test_s3_backend_rejects_auth_or_network_error_without_aws_code

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@takemi-ohama
Copy link
Copy Markdown
Contributor Author

PR レビュー対応サマリ (round 5)

直前ラウンドの codex / gemini レビュー指摘 2 件をすべて修正しました。

指摘元 重要度 対象 対応
codex major lib/devbase/env/bundle.py:251 (export 側 validator 不整合) fixed
gemini minor lib/devbase/env/storage.py:199 (NoSuchBucket / auth error と unsafe フラグ) fixed

主な変更

  • bundle.is_valid_project_name() を導入し、import / export 両側の project 名 validator を契約として同期。make_entries_from_disk() は invalid な project ディレクトリを warning + skip で除外し、round-trip 不能な bundle を作らないようにする。
  • _verify_bucket_encryption: NoSuchBucket および code が取れないローカルエラー (auth / network) は --unsafe-allow-unencrypted-bucket に関わらず即 StorageError

テスト

  • 回帰テスト 5 件追加 (bundle 4 / storage 2)
  • 既存テスト 160 件すべて pass

Counts

  • fixed: 2
  • deferred: 0
  • rejected: 0

commit: 3c5297d

@takemi-ohama
Copy link
Copy Markdown
Contributor Author

🔄 cross-review ループ進行中のため、本 PR を close し新規 PR に巻き直します。round_in_pr=5 で長尺化を回避。

takemi-ohama added a commit that referenced this pull request May 23, 2026
(cross-review rotation: PR #13 を squash 統合)
takemi-ohama added a commit that referenced this pull request May 23, 2026
* chore(PLAN03-1): release ブランチ作成

* feat(env): PLAN03-1 PR1 devbase env export (Local + Stdio) (#14)

* chore(PLAN03-1-export-local): Draft PR 作成

* feat(env): devbase env export を追加 (PLAN03-1 PR1)

- lib/devbase/env/bundle.py: tar.gz + manifest.yml バンドル構築/展開、sha256 検証、未知 version 拒否、パストラバーサル拒否
- lib/devbase/env/cipher.py: pyrage 経由の age 暗号化/復号 (X25519 / OpenSSH ed25519,rsa / passphrase / @path 参照)
- lib/devbase/env/storage.py: Local + Stdio backend、s3/gs は本 PR では未実装で明示エラー
- lib/devbase/env/io_export.py: 機密キー検知警告、既定鍵 (~/.ssh/id_rsa.pub) 自動利用、--passphrase-stdin と DEST='-' 併用拒否
- cli.py / commands/env.py: env export サブコマンド登録 + SUBCMD_MAP 更新
- pyproject.toml: pyrage>=1.2 を deps、pytest>=8.0 を dev group、tool.pytest.ini_options 追加
- tests/env, tests/cli: ラウンドトリップ + 異常系 28 件

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(env): レビュー指摘の修正 (storage/bundle/cipher)

- storage.py: LocalBackend で file:// URI を url2pathname で実パスへ変換
- bundle.py: manifest.files の要素型 (dict, path: str, sha256: str) を検証
- cipher.py: age 秘密鍵判定をバイト列で行い、UTF-8 デコード失敗を明示エラー化

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(env): round 2 レビュー指摘の修正 (堅牢性 + test 追加)

- storage: file:// URI の netloc が空/localhost 以外なら StorageError で拒否 (codex major)
- bundle: tar 内の重複エントリを BundleError で検出 (codex major)
- cipher: _resolve_recipient の @path 再帰に深さ制限 (上限 5) を追加 (gemini minor)
- tests/storage: file:// URI roundtrip と remote host 拒否の test を追加 (gemini minor)
- tests/bundle: _validate_manifest 不正系 (files が list でない / entry が dict でない /
  path 不正 / sha256 不正) + 重複エントリの test を追加 (gemini minor)
- tests/cipher: @path 循環参照で CipherError を返す test を追加 (gemini minor)

* fix(env): sha256 必須化と ed25519 デフォルト鍵対応 (round 3)

- bundle.py: manifest.files[*].sha256 を必須の 64 文字 16 進文字列として検証
  None / 欠落 / 長さ違い / 非 16 進は BundleError。完全性チェック迂回を防止
- cipher.py: default_recipient_paths / default_identity_paths に
  ed25519 (id_ed25519.pub / id_ed25519) を追加し、rsa より優先
- tests: sha256 欠落 / None / 長さ違い / 非 16 進の異常系テストを追加
- tests: ed25519 がデフォルトパス候補に含まれ rsa より優先されることを検証

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(env): round 4 レビュー指摘の修正 (異常系の堅牢化)

- bundle: yaml.safe_load の結果が dict でない場合に BundleError を送出
  (top-level が list/str/数値の場合に AttributeError が漏れるのを防止)
- cipher: @path 参照ファイルが UTF-8 でない場合 CipherError に包んで再送出
  (UnicodeDecodeError が呼び出し側に漏れていた)
- storage.resolve: Windows ドライブレター (C:\path 等) を urlparse が
  scheme と誤認する問題に対応し LocalBackend にフォールバック

各修正に対応する異常系 test を追加 (合計 +5 test)。

* fix(env): _resolve_identity の OSError を CipherError に包む (round 5)

- lib/devbase/env/cipher.py: path.read_bytes() を try/except OSError で
  ラップし、I/O エラー時も CipherError で統一されたエラー型を返す
- tests/env/test_cipher.py: monkeypatch で read_bytes に OSError を
  発生させて CipherError に包まれることを検証する test を追加

gemini round 5 指摘 (minor / 堅牢性) に対応。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(env): round 6 レビュー指摘の修正 (決定性 + 完全性 + 堅牢性)

- bundle.pack: gzip.GzipFile(mtime=0) でラップし出力を完全に決定的にする
- bundle._validate_manifest: tar 内ファイルセットと manifest の完全一致を
  検証し、manifest に記載のない未知ファイルを BundleError で拒否する
- cipher._resolve_recipient: @path の read_text で発生する OSError を
  CipherError に包んで一貫したエラーハンドリングにする
- cipher._resolve_identity: OpenSSH ヘッダで先に SSH 鍵を判別する分岐を
  追加し、鍵形式判別を明示化 (将来の形式追加もしやすくする)
- tests: pack 決定性 / unknown file 拒否 / @path OSError ラップ /
  OpenSSH ヘッダ優先判別の test を追加

* fix(env): @path 参照ファイルのコメント・空行をスキップする (round 6 追加)

recipient ファイルにコメント (# 始まり) や空行が混在していても扱えるよう、
有効な最初の行のみを採用する。テストも追加。

* fix(env): round 1 レビュー指摘の修正 (TOCTOU + BundleError 統一 + prefix 互換 + completion)

- storage.py: LocalBackend.write_bytes を os.open(mode=0o600, O_CREAT|O_TRUNC|O_WRONLY) で
  作成時点から 0600 を強制し、umask に依らない TOCTOU 安全な書き込みに変更
  (codex major / gemini minor — 同一指摘)。既存ファイル上書き時も先に chmod で権限を絞る。
  read_bytes / write_bytes の OSError を StorageError にラップ (gemini minor)。
- bundle.py: unpack() の tarfile.open / getmembers / extractfile で発生する
  tarfile.TarError / OSError を BundleError にラップ (gemini major)。
  make_entries_from_disk の exists() を is_file() に変更し、対象パスが
  ディレクトリだった場合の IsADirectoryError を防止 (gemini minor)。
  _validate_manifest に manifest.files の path 重複検出を追加 (codex minor)。
- cli.py: SUBCMD_PREFIX_PREFERENCES を追加し、`devbase env e` が引き続き edit に
  解決されるように prefix 解決の後方互換を維持 (codex minor)。
- etc/devbase-completion.bash, etc/_devbase: env export サブコマンドと
  各オプションを補完に追加 (codex minor)。
- tests: storage の TOCTOU / OSError ラップ / 既存ファイル 0600 上書き、
  bundle の path 重複 / 壊れた tar / is_file 切替、CLI prefix の後方互換テストを追加。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(env): round 2 advisory レビュー指摘の修正 (docstring / help / stdin prompt)

- io_export.py: `_resolve_recipients` の docstring を更新し、既定鍵が
  `id_ed25519.pub` → `id_rsa.pub` の優先順で探索される実態に合わせる
- cli.py: `--recipient` の help を `Default: ~/.ssh/id_ed25519.pub, then
  ~/.ssh/id_rsa.pub (first existing one)` に修正
- io_export.py: `--passphrase-stdin` で `sys.stdin.isatty()` の場合に
  `passphrase: ` プロンプトを stderr に表示し、対話実行時のハング感を解消
- 暗号化キー未指定エラーメッセージも ed25519 優先を反映
- tests/cli/test_env_export.py: tty / 非 tty 双方の挙動を検証する 2 ケース追加

Refs: PR #14 review comments 3280597873 / 3280597877 / 3280597881

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(env): PLAN03-1 PR2 devbase env import (#15)

* chore: PLAN03-1 PR2 Draft PR 作成 (import 実装)

* feat(env): PLAN03-1 PR2 devbase env import (Local + Stdio)

`devbase env import` を追加し、export で生成したバンドル (age 暗号化済み or
平文 tar.gz) から `.env` 群を復元できるようにする。

主な機能:
- merge セマンティクス: --merge keep-existing (既定) / prefer-incoming
- --replace-keys: 指定キーのみ上書き
- --replace: 対象 .env を丸ごと差し替え (backup 取得)
- --dry-run: 差分プレビュー (書き込みなし)
- 2 フェーズ書き出し (prepare → commit) で部分適用を最小化、失敗時は
  backup から best-effort で rollback
- --backup-dir / --keep-last N (既定 10) で backup を GC
- .env.sources.yml は既定で上書きせず参照用コピーのみ、--merge-metadata で
  新規 source エントリのみ追加、--no-metadata で完全無視
- 暗号化判定: gzip magic で平文 / age 暗号化を識別
- 引数バリデーション: SOURCE='-' と --passphrase-stdin の併用拒否、
  --passphrase-env と --passphrase-stdin の併用拒否、--replace と
  --replace-keys の併用拒否
- 既定 identity: ~/.ssh/id_ed25519 → ~/.ssh/id_rsa の順で探索

テスト (tests/cli/test_env_import.py, 17 ケース):
- export → import の round-trip、0600 permission 保持、LF 保持
- merge モード別の挙動 (keep-existing / prefer-incoming / replace-keys /
  replace)、dry-run が変更しないこと、replace 時の backup 作成
- --include-project / --no-metadata / --merge-metadata の挙動
- 未知 manifest version のバンドル拒否、--keep-last による古い backup GC
- 平文バンドル import、passphrase round-trip

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(env): import の merge/rollback/GC 安全性を改善

cross-review round 1 で指摘されたデータ損失・部分適用・merge 不整合を修正:

- --replace-keys 指定外でも既存に無い incoming 新規キーは追加するように修正
  (CLI help の "other keys behave like keep-existing" に整合)
- _rollback で op='create' なターゲットは backup が無くても unlink し、
  commit 途中失敗時の部分適用残骸を消す
- _gc_backups は devbase が生成する timestamp 形式 (YYYYMMDD-HHMMSS) の
  ディレクトリのみを削除対象にし、--backup-dir 親に置かれた無関係なファイル
  /ディレクトリを誤って消さないようにする
- EnvFile.parse_bytes(data) を新設し、io_import の bytes パースを
  一時ファイル経由から直接パースに置き換え (I/O 削減 + 例外安全)
- 上記 3 件分の回帰テストを追加

Refs: codex review #4336666744 / gemini review #4336672519

* fix(env): import の二重エスケープ / rollback / tmp 残骸 / completion を修正

PR #15 round 2 のクロスレビュー指摘 (codex 3 件 + gemini 2 件) に対応。

* EnvFile.parse_bytes に double-quote 内 escape の逆変換 (state machine) を
  追加し、save / _format_env_bytes との round-trip を成立させる。これにより
  backslash / quote / 改行を含む値が import → export を経て二重エスケープ
  される問題を解消する。
* _plan_env_merge の create パスでは _format_env_bytes による再シリアライズを
  避け、incoming_bytes をそのまま採用する。バンドル側のバイト列を完全に
  保持し、parse/format に潜む副作用を確実に排除する。
* _rollback で「backup が無い = 元ファイル不在」のケースを op に関係なく
  unlink するように変更。op='sources-merge' で sources.yml を新規作成した
  ロールバックでも残骸が残らなくなる。
* _commit 失敗時に、まだ rename されていない .import.tmp ファイルを
  try-finally で確実に削除する。
* _make_backup_dir に microsecond + 連番フォールバックを付与し、同一秒内に
  import が複数回走っても backup ディレクトリが衝突しない。
* etc/devbase-completion.bash と etc/_devbase の env サブコマンド一覧に
  import を追加し、各オプションも補完できるようにする。

テストでは parse_bytes round-trip / create 経路の byte preservation /
sources.yml rollback unlink / tmp cleanup / backup 衝突回避を検証する。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(env): import の merge でコメントを保持し $ をエスケープする

- ``EnvFile.parse_entries`` を追加し、コメント / 空行 / kv を行単位で
  トークン化できるようにする (PR #15 gemini 指摘)。
- ``_merge_into_existing_bytes`` で既存 ``.env`` のコメント・空行・キー順を
  保持したまま値を差し替えるよう merge 経路を変更。``EnvFile.dump_bytes``
  への単純差し替えではコメントが失われていた。
- ダブルクオート値に含まれる ``$`` を ``\$`` にエスケープし、``parse_bytes``
  側でも ``\$`` を ``$`` に戻すように round-trip 対応 (シェル ``source`` 時の
  意図しない変数展開を防止 / PR #15 gemini 指摘)。
- ``EnvFile.dump_bytes`` / ``EnvFile.dump_entries_bytes`` にフォーマット
  ロジックを集約し、``io_import._format_env_bytes`` を廃止。``EnvFile.save``
  も ``dump_bytes`` 経由に統一して二重実装を解消 (PR #15 gemini 指摘)。
- 新規テスト: コメント保持マージ (prefer-incoming / keep-existing)、
  ``$`` の round-trip、``EnvFile.dump_bytes`` のエスケープ仕様を追加。

* refactor(env): _plan_env_merge の重複を _build_merge_plan に共通化

各マージ戦略 (replace_keys / keep-existing / prefer-incoming) で個別に書かれていた
new_bytes 生成と _Plan 構築を `_build_merge_plan` にまとめた。各分岐は
merged / added / overwritten / skipped の計算だけを担当するように整理。
動作変更なし (PR #15 gemini round4 指摘 / 既存テスト 102 件 PASS)。

* fix(env): コメントのみ既存 .env を merge 経路に通す + env i 短縮維持

- _build_merge_plan / _plan_env_merge / replace ブランチの op 判定を
  existing (key=value dict) の有無から target.exists() に変更。
  コメント / 空行のみで構成された既存 .env が「create」と誤判定されて
  incoming_bytes で上書きされ既存コメントが失われる問題を修正
  (PR #15 round5 codex/gemini 指摘)。
- SUBCMD_PREFIX_PREFERENCES に i: init を追加。import 追加で
  devbase env i が init / import の両方にマッチして ambiguous に
  なっていたため、既存ショートカット (devbase env i → init) を維持する
  (PR #15 round5 codex 指摘)。
- 回帰テスト追加:
  - tests/cli/test_env_import.py: コメント/空行のみの既存 .env が
    prefer-incoming / keep-existing / replace-keys でコメント保持される
    こと。--replace ブランチでも op='replace' として報告され backup が
    取られること
  - tests/cli/test_prefix_resolution.py: devbase env i → init,
    devbase env im → import, devbase env in → init

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(env): import の sys を先頭 import に / TTY 時 getpass.getpass でエコー抑止

gemini round 6 で挙がった minor 指摘 2 件に対応:

1. lib/devbase/env/io_import.py:97 `import sys` がローカル import になっていたのを
   ファイル先頭の標準 import セクションに移動 (`import getpass` も同時追加)。
   メンテナンス性向上、PEP 8 への準拠。
2. lib/devbase/env/io_import.py:100 TTY 入力時に `sys.stdin.readline()` を使って
   いたため、パスフレーズがそのまま画面にエコーバックされていた。
   `getpass.getpass(prompt, stream=sys.stderr)` を使うように変更。
   パイプ入力時 (非 TTY) は従来どおり `sys.stdin.readline()` で読み (
   stdin リダイレクト経由のテストフックとして必要)、getpass の EOFError は
   `ImportError("stdin からパスフレーズを読み取れませんでした")` に変換する。

回帰テスト 3 件を追加 (tests/cli/test_env_import.py):
- test_read_passphrase_uses_getpass_on_tty
- test_read_passphrase_falls_back_to_stdin_on_pipe
- test_read_passphrase_tty_eof_raises_import_error

ローカル品質チェック:
- uv run pytest tests/ -> 112 passed (107 + 3 + 既存 review round で +2)
- uv run python -m compileall lib bin -> OK
- uvx ruff check --select=E9,F63,F7,F82 lib -> All checks passed

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(env): export 側にも TTY エコー抑止と先頭 import を適用 (import と対称化)

PR #15 round 7 で io_import.py に入れた修正 (commit 454425e) を、対称な
io_export.py 側にも適用する。両者は _read_passphrase の構造が同じで、
TTY エコー抑止と import 位置の問題も同じパターンで残っていた。

- lib/devbase/env/io_export.py:
  - ローカル `import sys` をファイル先頭の標準 import セクションに移動
  - `import getpass` を追加
  - TTY 入力時に `getpass.getpass("passphrase: ", stream=sys.stderr)` を
    使うように変更。EOFError は ExportError("stdin からパスフレーズを
    読み取れませんでした") に変換
  - パイプ入力時 (非 TTY) は従来どおり `sys.stdin.readline()` を使用

- tests/cli/test_env_export.py:
  - 旧テスト test_read_passphrase_shows_prompt_on_tty /
    test_read_passphrase_no_prompt_on_pipe は `print` 経由の prompt 表示を
    検証する構造だったため、getpass.getpass をモックする方式に書き換え
  - 追加: test_read_passphrase_uses_getpass_on_tty (TTY 時に
    getpass.getpass が呼ばれ stdin は消費されないこと)
  - 追加: test_read_passphrase_falls_back_to_stdin_on_pipe (非 TTY では
    getpass は呼ばれず stdin.readline 経路に入ること)
  - 追加: test_read_passphrase_tty_eof_raises_export_error (EOFError →
    ExportError 変換)

ローカル品質チェック:
- uv run pytest tests/ -> 113 passed
- uvx ruff check --select=E9,F63,F7,F82 lib -> All checks passed

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(env): _unescape_double_quoted を re.sub + 逆引き辞書に書き換え

PR #15 ユーザーレビュー (lib/devbase/env/store.py:160) で挙がった
「pythonic に書き直してください」への対応。

before: バックスラッシュ + 次文字を 1 文字ずつ走査する hand-rolled
state machine (25 行)。
after:  re.compile(r'\\.') で \<char> を一括捕捉し、逆引き辞書
{'\\\\': '\\', '\\"': '"', '\\n': '\n', '\\$': '$'} で 1 行置換する
re.sub 呼び出し 1 つに集約 (5 行)。

挙動は state machine と完全に同等:
- 未知エスケープ (例: \x) は dict.get の default にマッチ文字列
  自身を返すことでバックスラッシュごと保持
- 末尾の単独 \ は \\. のドットが 2 文字目を要求するため自然に
  マッチせず、そのまま保持
- \\\\n (リテラル \\ + n) と \\n (改行) も re.sub は左から非重複で
  マッチするため state machine と同じ結果

差分: 43 → 15 行 (net -28 行 削除 / +15 行 追加 = -13 行)。
既存テスト (test_envfile_parse_bytes_round_trip_with_escapes 等) で
double-quote escape の round-trip は完全に検証済みのため新規テストは追加せず。

ローカル品質チェック:
- uv run pytest tests/ -> 113 passed
- uvx ruff check --select=E9,F63,F7,F82 lib -> All checks passed
- uv run python -m compileall lib bin -> OK

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(env): PLAN03-1 PR3 devbase env export/import S3 backend (#19)

* chore: PLAN03-1 PR3 Draft PR 作成 (S3 backend)

* feat(env): PLAN03-1 PR3 devbase env export/import S3 backend

- `s3://bucket/key` を `devbase env export` / `devbase env import` の
  入出力先として指定できるようにする
- export 時は ServerSideEncryption (`aws:kms` 既定, `AES256` 切替可) を
  常に PutObject に付与し、加えて GetBucketEncryption で **バケット側の
  既定暗号化** も事前確認する
- 暗号化未設定 / 確認不可 (AccessDenied) のバケットへは
  `--unsafe-allow-unencrypted-bucket` を明示しない限り export を拒否する
  (オブジェクト単位の SSE はこのフラグに関係なく常に付与される)
- SSE 種別 / KMS 鍵 / エンドポイント / リージョンは環境変数
  (`DEVBASE_S3_SSE`, `DEVBASE_S3_SSE_KMS_KEY_ID`,
  `DEVBASE_S3_ENDPOINT_URL`, `DEVBASE_S3_REGION`) で上書きできる
- `boto3` は optional dep として `[project.optional-dependencies] s3`
  に追加 (`pip install 'devbase[s3]'` でインストール)
- `gs://` (GCS) は PLAN03-1 PR4 廃案のため明示エラーで拒否する

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(env): PLAN03-1 PR3 storage.py minor 修正 (cross-review round 1)

PR #19 のクロスレビュー (codex / gemini) で指摘された minor 3 件に対応。

- `_parse_s3_uri`: `urlparse` は S3 キーに含まれる `?` / `#` を query /
  fragment として落としてしまうため、AWS CLI と同じ挙動になるよう
  スキームを除去した上で `partition('/')` で分割する。
- boto3 未インストール時のエラーメッセージを `pip install boto3` から
  本プロジェクトの optional dependency 経由
  (`pip install 'devbase[s3]'` / `uv add 'devbase[s3]'`) に変更。
- `_verify_bucket_encryption`: MinIO / LocalStack 等の S3 互換ストレージで
  GetBucketEncryption が NotImplemented を返すケースに備え、
  `--unsafe-allow-unencrypted-bucket` 指定時は未知エラーも警告のみで続行する
  逃げ道として機能させる (CHANGELOG の S3 互換ストレージ対応との整合)。

新規テスト: query/fragment 保持、未知エラーの拒否、unsafe フラグでの続行を追加。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(env): PLAN03-1 PR3 boto3 を main dependency に昇格

boto3 を `[project.optional-dependencies].s3` から `[project].dependencies`
に移し、ImportError ハンドラとフォローアップ案内文を撤去する。

意図:
- S3 URI を初めて指定したユーザに `pip install 'devbase[s3]'` を
  打たせる UX を廃する。25MB 程度のコスト増 (botocore 24MB) は
  実装複雑度ゼロと引き換え。
- 引数検出 (`s3://` 走査) や lazy 自動 install を採らないのは、
  CI / オフライン / read-only コンテナで挙動が安定するため。

storage.py / test_storage.py の boto3-missing 関連コードを削除。
CHANGELOG.md の optional 記述も同期更新。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(env): PLAN03-1 PR5 ドキュメント追加 + import/export リファクタ (#20)

* refactor(env): PLAN03-1 PR5 env export/import モジュールを整理

- 711 行に肥大化していた `io_import.py` を以下の 3 モジュールに分割する。
  公開 API (`ImportOptions`, `import_bundle`, `ImportError`) は維持し、
  テストの `_read_passphrase` 直接 import や `getpass` パッチも継続して動く:
  - `io_import.py` (209 行): 引数検証 / 復号判定 / 全体オーケストレーション
  - `_import_merge.py`: `Plan` データクラス、`plan_env_merge` / `plan_sources`、
    既存コメント・空行を保持した merge ロジック、ログ整形
  - `_import_atomic.py`: 2 フェーズ書き込み (`backup_existing` → `write_atomic`
    → `commit`)、`gc_backups`、ロールバック

- export / import で重複していた共通 helper を `io_common.py` に集約する:
  - `read_passphrase()` — env / stdin 入力、tty 時の getpass エコー抑止
  - `resolve_recipient_specs()` / `resolve_identity_specs()` — 省略時の
    `~/.ssh/id_ed25519(.pub)` → `id_rsa(.pub)` fallback
  - `write_secure_bytes()` — `os.open(O_WRONLY|O_CREAT|O_TRUNC, mode=0o600)` で
    TOCTOU を避けてセキュアにバイト列を書き出す共通実装。`storage.LocalBackend`
    と `_import_atomic` から呼び出す

- `_plan_env_merge()` の 4 段ネスト if/elif を 4 つの小さな関数
  (`_plan_replace` / `_plan_replace_keys` / `_plan_keep_existing`
  / `_plan_prefer_incoming`) に分割し、`plan_env_merge` 本体はモード選択のみに
  簡素化する

- `storage.LocalBackend.write_bytes` を `io_common.write_secure_bytes`
  への薄いラッパに置き換え、重複していた `os.open` + chmod パターンを削除

- `io_export.py` (185 → 168 行) の `_read_passphrase` / `_resolve_recipients`
  は `io_common` への delegation に置き換え、`encrypt_payload` / `validate_options`
  の helper 関数に export 本体のロジックを分解

- `_commit()` 移動に伴い、テスト 3 件の `monkeypatch.setattr(_io_import.os, 'replace', ...)`
  パッチ先を `_import_atomic.os` に更新。`log_plans` 移動に伴う caplog の
  logger 名も `devbase.env._import_merge` に追従

リファクタの動機:
- io_import.py が PR1〜PR3 を通じて 711 行まで肥大化し、merge 計画 / atomic 書き込み
  / orchestration が同居して読みづらかった
- io_export と io_import で `_read_passphrase` / 既定鍵 fallback / セキュア書き込みが
  ほぼ重複していた

挙動の変更は無く、全 136 テストが引き続き green を維持する。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(env): PLAN03-1 PR5 env export/import 利用者向けガイドを追加

`docs/user/env-export-import.md` (456 行) を新設し、以下を網羅する:

- 対象ファイル一覧 (global / projects/*/.env / .env.sources.yml) と
  公開可能な雛形 (`projects/*/env`) を含めない設計理由
- クイックスタート (既定鍵での export → 別マシンでの import)
- バンドル構造 (manifest.yml の sha256 検証、version 互換ポリシー)
- age 暗号化: recipient / identity / passphrase の 3 方式、対応鍵種別表、
  ssh-ecdsa 非対応への対処 (`age-keygen` / `ssh-keygen -t ed25519`)、
  既定鍵 (`~/.ssh/id_ed25519` → `id_rsa`)
- 入出力先: ローカル / stdio / S3。S3 の SSE 強制と
  `--unsafe-allow-unencrypted-bucket`、`DEVBASE_S3_*` 環境変数
- export / import の全オプション表、merge モード (keep-existing /
  prefer-incoming / --replace-keys / --replace) の動作比較
- `.env.sources.yml` の取り扱い (既定スキップ + 参照用コピー、
  `--merge-metadata` での新規エントリ追加)
- 2 フェーズ書き込み + backup + ロールバックの仕組み、
  `--keep-last N` での GC、ACID 非保証の注意
- 典型ワークフロー 4 件 (別マシン移行 / 定期バックアップ / S3 チーム共有 / CI 配布)
- トラブルシューティング 8 件

加えて以下のリンクを追加:
- `README.md`: 「利用者向け」ドキュメント表に env-export-import.md への
  リンクを追加し、`env` グループの説明に export / import を併記
- `docs/user/environment-variables.md`: 「別マシンへの移行 / バックアップ」
  節を新設して env-export-import.md へ誘導、ベストプラクティスに追記
- `CHANGELOG.md` (Unreleased): docs 追加とリファクタリングを記載

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(env): warning ログの文面矛盾を解消 (unsafe フラグ続行時)

`_verify_bucket_encryption` で `--unsafe-allow-unencrypted-bucket` 指定時に
「export を中止します。(unsafe フラグにより続行)」という矛盾した警告が出ていた。
問題説明 (problem) と対処案内 (guidance) を分離し、warning は problem のみ、
StorageError raise 時は問題+対処案内を出すよう統一。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(env): PR #13 round1 codex/gemini 指摘対応

- _import_merge: 未対応 arcname を黙って捨てず MergeError で停止
  (filter_members の logger.debug+continue → raise MergeError)
- _import_merge: merge 経路で値が変更されていないキーは raw 行を温存し、
  PATH=$HOME/bin のような未クオート値が PATH="\$HOME/bin" に勝手に
  エスケープされて source 時の意味が変わるのを防ぐ
- cipher: age-keygen 出力の先頭コメント (# created / # public key) を
  考慮し、行単位でコメント / 空行を除いて AGE-SECRET-KEY-1 行を検出
- 上記 3 件に対する回帰テストを追加

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(env): PR #13 round2 gemini 指摘対応

- io_export._validate_options: DEST='-' (stdout) と --passphrase-stdin の
  排他チェックを削除。stdin (passphrase) と stdout (bundle) は別ストリーム
  のため衝突しない (例: `echo pass | devbase env export - --passphrase-stdin > out`)
- io_common.read_passphrase: stdin から読んだ行末を rstrip('\n') から
  rstrip('\r\n') に変更。Windows/WSL 由来 CRLF パイプで末尾 \r が残ると
  age 復号が無音で失敗するため。
- tests: 排他テストを併用許可テストに置換 + CRLF rstrip 回帰テスト追加

Refs: #13 (review)

* fix(env): PR #13 round3 codex 指摘対応

- _import_merge.py の project 名正規表現を厳格化し、`./..`/隠しディレクトリを
  弾く (path traversal: `env/projects/./.env` が `$DEVBASE_ROOT/projects/.env`
  に解決される問題への対策)。
- 上記の回帰テストを `tests/cli/test_env_import.py` に追加。
- zsh/bash 補完に `--unsafe-allow-unencrypted-bucket` (env export) を追加し
  CLI 定義と同期。

* fix(env): PR #13 round4 gemini 指摘対応

- cipher.py: `@PATH` ファイルに複数行の鍵が含まれる場合は明示的に
  CipherError を投げる。team_keys.txt のような複数公開鍵列挙を
  暗黙に「最初の 1 人」だけ扱う挙動は、チーム運用で暗号化バンドル
  が壊れる原因になるため誤運用を防ぐ
- io_common.py: `resolve_identity_specs` で `~/.ssh/id_ed25519` と
  `~/.ssh/id_rsa` の両方が存在する場合、両方を返すように変更。
  `pyrage.decrypt` は複数 identity を受け付け、バンドルに合致する
  鍵だけが使われる。これにより RSA で暗号化されたバンドルを
  ed25519 鍵だけで開けず失敗する問題を解消
- resolve_recipient_specs は意図的に最初の 1 つを返す挙動を維持
  (どの鍵で暗号化するか一意に決める必要があるため)
- 複数鍵ファイル拒否と複数 identity 復号の回帰テストを追加

* fix(env): PR #13 round5 codex/gemini 指摘対応

- bundle.is_valid_project_name() を導入し、export 側
  (make_entries_from_disk) でも import 側 (_PROJECT_ENV_RE) と同じ
  project 名 validator を適用する。空白 / 先頭 `.` / `-` 等を含む
  ディレクトリは警告のみで skip し、round-trip 不能な bundle が
  作られるのを防ぐ (codex round 5 指摘)。
- _import_merge._PROJECT_ENV_RE を bundle._VALID_PROJECT_NAME_RE
  から組み立てるよう変更し、import / export 両側の validator を
  契約レベルで同期させる。
- S3Backend._verify_bucket_encryption で NoSuchBucket / 認証・接続
  系エラー (code が取れないケース) は --unsafe-allow-unencrypted-bucket
  の有無に関わらず即 StorageError を投げる。続行しても put_object が
  同じエラーで再失敗するだけのため、早期にエラーを返してユーザの
  トラブルシューティングを助ける (gemini round 5 指摘)。
- 回帰テスト追加:
  - test_is_valid_project_name / test_make_entries_from_disk_skips_invalid_project_names
  - test_make_entries_from_disk_invalid_name_explicitly_included_is_still_skipped
  - test_make_entries_from_disk_validator_matches_import_side (契約同期)
  - test_s3_backend_rejects_no_such_bucket_even_with_unsafe_flag
  - test_s3_backend_rejects_auth_or_network_error_without_aws_code

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: 不要な migrate_ai_to_home.sh を削除

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(env): recipient と passphrase 同時指定を拒否 + docs 表現修正

- io_export._encrypt_payload で passphrase ありかつ opts.recipients
  指定時に ExportError を送出。従来は recipients=[] に上書きされて
  cipher.encrypt 側の同時指定チェックを silently バイパスし、ユーザ
  が明示した --recipient が無視されていた (gemini round 6 指摘)。
- docs/user/env-export-import.md L201 の制約説明を修正。round 2 で
  export 側の DEST='-' x --passphrase-stdin 排他チェックを撤廃した
  ため、SOURCE='-' (import) のみ併用不可、export は併用可能であること
  を明記 (codex round 6 指摘)。
- recipient + passphrase 併用拒否の回帰テストを追加。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(env): PR #22 round1 codex/gemini 指摘対応

- bundle.py: manifest version チェックを厳密一致 (!=) に変更し、
  0 や負数の未知スキーマを拒否 (codex major 指摘)
- cli.py: import --identity の help を "(all existing ones)" に修正
  (codex minor 指摘、resolve_identity_specs は全鍵を返す)
- io_import.py: --identity と --passphrase-* の同時指定を
  _validate_options で拒否 (gemini major 指摘)
- テスト追加: version=0/-1 の拒否、identity+passphrase 排他チェック

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(env): export オプション排他チェックを _validate_options に集約 (fail-fast)

- --recipient と --passphrase-* の排他チェックを _encrypt_payload から
  _validate_options へ移動 (ディスク I/O 前に弾く)
- --force-unencrypted と鍵指定の排他チェックを export() から
  _validate_options へ移動
- io_import._validate_options と対称的な構造に統一
- _validate_options 直接テスト 5 件追加

PR #22 round2 gemini [major / 堅牢性] 指摘対応

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(env): backup GC に dbenv- prefix で安全弁 + export 既定名を microsecond 精度に

PR #22 round3 codex 指摘対応:

1. [major] _import_atomic.py: backup ディレクトリ名に `dbenv-` prefix を付与し、
   --backup-dir 親に無関係なタイムスタンプ形式ディレクトリがあっても GC で
   rmtree しないようにした。旧フォーマット (prefix なし) は後方互換で GC 対象。

2. [minor] io_export.py: 既定出力名を秒精度から microsecond 精度に変更し、
   同一秒の複数 export による無言上書きを防止。加えて既定名使用時に同名
   ファイルが既に存在する場合は ExportError で失敗させる。

テスト追加:
- test_gc_backups_ignores_bare_timestamp_dirs_from_other_tools
- test_default_dest_includes_microsecond
- test_export_default_dest_rejects_existing_file

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(env): opts.dest 空文字で既定名ガードがバイパスされる問題を修正

`opts.dest` が `""` (空文字) の場合、`opts.dest or _default_dest(...)` で
既定名に置換されるが、`if opts.dest is None` チェックが `False` となり
同名ファイル存在時の ExportError ガードがバイパスされていた。
`if not opts.dest` に修正し、空文字も None と同等に扱うようにした。

テスト `test_export_empty_dest_rejects_existing_file` を追加。

PR #22 round4 gemini 指摘対応

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant